﻿namespace Elmah
{
    #region Imports

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Text.RegularExpressions;
    using System.Globalization;

    #endregion

    /// <summary>
    /// Class to convert English strings to a pair of date times. Not a full-blown implementation,
    /// just enough to help Elmah along.
    /// </summary>
    public class FuzzyDateParser
    {
        private DateTime Now
        {
            get { return DateTime.Now; }
        }

        /// <summary>
        /// Attempts to parse a string representation of a date (English only) into a pair of <code>DateTime</code>
        /// structures which are a "half-open interval", i.e. the "from" time is included in the interval
        /// but the "to" time is not. You should use <code>>= from</code> and <code>&lt; to</code> in your date
        /// expressions. As an example, "today" would be returned as "2012-04-24" and "2012-04-25" if DateTime.UtcNow
        /// is any time in the 24th of April.
        /// </summary>
        /// <remarks>
        /// Valid expressions are:
        ///     today, yesterday
        ///     jan[uary], feb[ruary] etc. (A bare month is always assumed to be in the past).
        ///     last [n] minutes[s], hour[s], day[s], week[s], month[s], year[s]
        ///     2012-04-20 to 2012-04-30, (with an optional timecomponent in the form hh:mm:ss)
        ///     2012-04-20
        ///     = 2012-04-20
        ///     > 2012-04-20 (treated same as >=)
        ///     >= 2012-04-20
        /// </remarks>
        /// <param name="dateString">
        /// The string to parse. See remarks for valid expressions.
        /// </param>
        /// <param name="from">The inclusive lower bound of the interval. Use the <code>>=</code> relation when 
        /// performing comparisons. Guaranteed to be DateTime.MinVal if parsing could not be performed or a
        /// lower bound is not required by the <paramref name="dateString"/>.</param>
        /// <param name="to">The exclusive upper bound of the interval. Use the <code>&lt;</code> relation
        /// when performing comparisons. Guaranteed to be DateTime.MinVal if parsing could not be performed or a lower
        /// bound is not required by the <paramref name="dateString"/>.</param>
        /// <returns>True if the <paramref name="dateString"/> could be parsed into 1 or more DateTime structures,
        /// false if it could not be parsed.</returns>
        public bool TryParse(string dateString, out DateTime from, out DateTime to)
        {
            from = DateTime.MinValue;
            to = DateTime.MinValue;
            if (dateString == null || dateString.Trim() == "") 
                return false;
            dateString = dateString.Trim().ToLowerInvariant();

            if (dateString == "today")
            {
                from = this.Now.Date;
                to = from.AddDays(1);
                return true;
            }

            if (dateString == "yesterday")
            {
                from = this.Now.Date.AddDays(-1);
                to = from.AddDays(1);
                return true;
            }

            bool success = TryMatchMonth(dateString, ref from, ref to);
            if (success) return true;

            success = TryMatchLastN(dateString, ref from, ref to);
            if (success) return true;

            success = TryMatchDateRange(dateString, ref from, ref to);
            if (success) return true;

            success = TryMatchSingleDateExpression(dateString, ref from, ref to);
            if (success) return true;


            return false;
        }

        /// <summary>
        /// Attempt to match "Jan", "Febuary" etc.
        /// </summary>
        private bool TryMatchMonth(string dateString, ref DateTime from, ref DateTime to)
        {
            int month_num = -1;
            DateTimeFormatInfo cur = DateTimeFormatInfo.CurrentInfo;
            DateTimeFormatInfo inv = DateTimeFormatInfo.InvariantInfo;
            for (int i = 0; i < cur.AbbreviatedMonthNames.Count(); i++)
            {
                if (cur.AbbreviatedMonthNames[i].Equals(dateString, StringComparison.CurrentCultureIgnoreCase) ||
                    cur.MonthNames[i].Equals(dateString, StringComparison.CurrentCultureIgnoreCase) ||
                    inv.AbbreviatedMonthNames[i].Equals(dateString, StringComparison.InvariantCultureIgnoreCase) ||
                    inv.MonthNames[i].Equals(dateString, StringComparison.InvariantCultureIgnoreCase)
                    )
                {
                    month_num = i + 1;      // In a DateTime, months are numbered 1..12
                    break;
                }
            }

            if (month_num == -1)
                return false;

            from = new DateTime(this.Now.Year, this.Now.Month, 1);
            while (from.Month != month_num)
                from = from.AddMonths(-1);
            to = from.AddMonths(1);
            return true;
        }

        /// <summary>
        /// // Attempt to match a "last x things" syntax.
        /// </summary>
        private bool TryMatchLastN(string dateString, ref DateTime from, ref DateTime to)
        {
            Regex re = new Regex
                (
                @"last[\t ]*(?<num>[\d]*)[\t ]*(?<timespan>minutes?|hours?|days?|weeks?|months?|years?)",
                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
                );

            Match m = re.Match(dateString);

            if (m.Success)
            {
                Group g_num = m.Groups["num"];
                int num = 1;
                if (g_num.Success)
                {
                    if (!Int32.TryParse(g_num.Value, out num))
                        num = 1;
                }

                Group g_timespan = m.Groups["timespan"];
                string span = "days";
                if (g_timespan.Success)
                {
                    span = g_timespan.Value;
                }

                span = span.Substring(0, 3);
                to = new DateTime(this.Now.Year, this.Now.Month, this.Now.Day, this.Now.Hour, this.Now.Minute + 1, 0);
                switch (span)
                {
                    case "min":
                        from = to.AddMinutes(-num);
                        break;
                    case "hou":
                        from = to.AddHours(-num);
                        break;
                    case "day":
                        from = to.AddDays(-num);
                        break;
                    case "wee":
                        from = to.AddDays(-num * 7);
                        break;
                    case "mon":
                        from = to.AddMonths(-num);
                        break;
                    case "yea":
                        from = to.AddYears(-num);
                        break;
                    default:
                        throw new Exception("Should never happen due to regex matching all cases.");
                }

                return true;
            }

            return false;
        }

        /// <summary>
        /// from here http://underground.infovark.com/2008/07/22/iso-date-validation-regex/
        /// </summary>
        private static string ISODateRegex
        {
            get
            {
                return @"([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))
                  ([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?";
            }
        }

        /// <summary>
        /// Attempt to match an expression of the form "date1 to date2."
        /// </summary>
        private bool TryMatchDateRange(string dateString, ref DateTime from, ref DateTime to)
        {
            Regex re = new Regex
                (
                @"(?<d1>" + ISODateRegex + @")(\s)*to(\s)*(?<d2>" + ISODateRegex + @")",
                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.IgnorePatternWhitespace
                );

            Match m = re.Match(dateString);

            if (m.Success)
            {
                Group d1 = m.Groups["d1"];
                Group d2 = m.Groups["d2"];

                if (d1.Success)
                {
                    if (!InternalTryParse(d1.Value, out from))
                        return false;
                }

                if (d2.Success)
                {
                    if (!InternalTryParse(d2.Value, out to))
                        return false;
                }

                return d1.Success && d2.Success;
            }

            return false;
        }

        /// <summary>
        /// Attempt to match an expression of the form "[>[=]] 2012-04-20 [12:33]"
        /// </summary>
        private bool TryMatchSingleDateExpression(string dateString, ref DateTime from, ref DateTime to)
        {
            Regex re = new Regex
                (
                @"(?<relation>\>|\>=|=|)?\s*" + ISODateRegex,
                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.IgnorePatternWhitespace
                );

            Match m = re.Match(dateString);

            if (m.Success)
            {
                Group g_relation = m.Groups["relation"];
                string relation = "";
                string dt = "";
                if (g_relation.Success)
                {
                    relation = g_relation.Value.Trim();
                    dt = dateString.Substring(relation.Length).Trim();

                    if (!InternalTryParse(dt, out from))
                        return false;

                    switch (relation)
                    {
                        case "":
                            to = from.AddDays(1);
                            return true;
                        case "=":
                            to = from.AddDays(1);
                            return true;
                        case ">":
                            // This is actually tricky, given our implicit definition of the interval as half-open,
                            // the only way to do ">" would be to set "from" to 1 tick more than the from that was
                            // parsed; but that is going to fall foul of rounding in the database system, e.g.
                            // SQL server datetime has a resolution of only 3.3msec. So, we will actually treat > and
                            // >= as >=. In practice, given what you are likely to be doing with Elmah, this will
                            // work OK.
                            to = DateTime.MinValue;
                            return true;
                        case ">=":
                            to = DateTime.MinValue;
                            return true;
                        default:
                            throw new Exception("Unhandled relation.");
                    }
                }
            }

            return false;
        }

        /// <summary>
        /// Try and parse a date, always assuming invariant culture and local time.
        /// </summary>
        private bool InternalTryParse(string input, out DateTime d)
        {
            return DateTime.TryParse(input, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out d);
        }
    }
}
